Skip to content

fix(data): a filter the server cannot apply is rejected, not silently ignored (#4181) - #4209

Merged
os-zhuang merged 4 commits into
mainfrom
claude/rest-list-unknown-query-params-6syn6f
Jul 30, 2026
Merged

fix(data): a filter the server cannot apply is rejected, not silently ignored (#4181)#4209
os-zhuang merged 4 commits into
mainfrom
claude/rest-list-unknown-query-params-6syn6f

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #4181#4134 / #4164 家族第三员,也是方向最恶性的一个:前两案分别是清零和丢一个谓词,这个是返回全部

更新:与 #4121 (#4200) 的会合。 本 PR 在途中,#4121 落到了同一段 filter 别名链上(拒绝不是合法 AST 的 $filter 数组)。两者是同一个条件的互补两半,已合并 origin/main 并做了语义上的对账——细节见下方「与 #4121 的对账」。CI 之前一直不跑正是因为这个冲突:mergeable_state: dirty 时 GitHub 造不出 merge commit,pull_request 工作流不触发。

问题

?filter={status:done(少一个引号)返回 200 + 未过滤整页。归一化层的 catch { /* keep as-is */ } 把原始字符串留在 where 上,没有驱动消费这个形状,于是过滤器整个蒸发,响应与一次成功的无过滤查询逐字节相同——调用方无从察觉自己写的过滤器从未生效。

这不是设计题:处方早就在同一个文件里

GET /data/:object/export 对同一份输入从写下来那天起就拒绝(rest-server.ts)。list 路由才是异类——同一端点家族的两条路由,对同一份畸形输入给出相反答案,而且被硬化的恰好是危害较小的那条(export 一次性、结果可见),没硬化的是常被程序化消费的 list。守卫因此搬进共享归一化层,GET /data/:objectPOST /data/:object/query、runtime dispatcher 三条路径继承同一个答案。

改动

  • 解析失败 → 400 INVALID_FILTER,点名参数并明说过滤器没有生效(It was not applied, and an unapplied filter would have returned the unfiltered result set)——失败模式本身才是这个 bug 的要害,错误信息必须说出来。
  • 解析成功但不是过滤器(?filter=5 / "done" / null)同样拒绝。能被 JSON 解析 ≠ 能当过滤器用;这一步也是让 REST 列表:显式 filter 在场时,同时传的字段级参数被静默丢弃(#4134 的邻居) #4164 的合并可以信任 where 是 object 的前提。
  • ?filter= 仍是「不存在」而非「畸形」——沿用 export 的 length > 0 判断,并删掉 where 而不是留一个 ''
  • 四别名不再静默分胜负filter / filters / $filter / where 是同一槽位的四种拼法,?? 取第一个非空 = 静默丢弃其余(body 同时带 where 和不同的 filter 时,跑的是 filter)。值不同 → 400 INVALID_REQUEST;刻意不选 AND 合并——合并会发明调用方没表达过的意图,而选一个就是静默丢弃本身。冗余的相同拼法放行。这里用 INVALID_REQUEST 而非 INVALID_FILTER:每个值单独看都是合法过滤器,含混的是请求,不是过滤器。
  • export 的 orderby 同规则(原本 catch { /* leave undefined */ })。危害低于 filter(结果集不变),但靠 orderby + top 取「最新 N 条」的调用方拿到的是任意 N 条。这条守卫此前没有任何测试,一并补上。

顺带清掉一处刚变成死代码的守卫

#4164 当时加了 typeof explicitWhere === 'object' 才允许合并,理由写在注释里:正是因为这个解析容忍可能在 where 上留下裸字符串。源头修掉后该分支不可达,连同注释删除;那条 pinned until #4181 的测试也从「保留垃圾」翻转为「拒绝垃圾」。这是当初刻意留的交接点。

#4121 的对账

git 解不了的是一个函数重名:两边都叫 malformedFilterError,签名和错误码都不同。对账结果:

覆盖 helper
#4121 畸形 filter 数组(坏操作符、孤立 ["and"]) malformedFilterArrayError
#4181 非数组的失败路径(JSON 解析失败;解析出 number / string / null) unusableFilterError

两者共用 INVALID_FILTER(标准目录码,#4121 引入)。调用方问的是「我的过滤器跑了吗」,不该需要知道是哪条分支拦下的——新增一条测试专门钉这点:四种不同坏法的 filter,只允许出现一个 400/INVALID_FILTER

链条顺序是两者能共存的关键:空 → 视为不存在;字符串 → 解析或拒绝;数组 → AST 转换或拒绝(#4121);标量 → 拒绝(#4181);活下来的一定是 object,这正是 #4164 的合并可以信任 where 的依据。空 [] 仍表示「无过滤器」,原样通过。

export 路由的 filter 守卫也从 INVALID_REQUEST 改为 INVALID_FILTER 与之统一。这条错误码此前是有测试钉住的——我先前用消息字符串 grep 没抓到断言 code 的那一行,是全量测试把它抓了出来;改动因此是有意的,测试里记下了理由。

验证

单测:#4121 的 12 项 + 本分支 103 项全过。本分支覆盖三个别名各自的解析失败、"没有生效"的措辞、四种「能解析但不是过滤器」的值、三种合法形状(JSON 字符串 / 活对象 / FilterAST 数组)、空 filter、别名冲突/相同/单发、以及跨两案的单一错误码。真实引擎测试先跑 baseline total=10 再证明垃圾 filter 不再返回同样的 10 行——否则「拒绝了」和「过滤器生效了」在断言上无法区分

跑起来的服务器(showcase,已自行关闭;下表跑在合并 #4121 之前的提交上,错误码当时还是 INVALID_REQUEST,行为与现在一致):

baseline                          -> 200 total=10
?filter={"status":"done"}         -> 200 total=2         ✅ 合法照常
?filter={status:done              -> 400                  (修复前 200 total=10)
?filter={status:done&top=2        -> 400                  (修复前 200 total=10 records=2,装模作样地分页)
?filter=5 / "done" / null         -> 400 ×3
?filter=  (空)                    -> 200 total=10         ✅ 空仍视为不存在
?filters=… / ?$filter=…           -> 200 total=2 ×2       ✅ 别名照常
POST body {where:done,filter:todo}-> 400 冲突;{…,filter:done} 相同 -> 200
export?filter=垃圾 / orderby=垃圾 -> 400 ×2;正常 export -> 200 6080B
?pageSize=5        (#4134)        -> 400 INVALID_FIELD    ✅ 不回归
?filter=…&status=todo (#4164)     -> 200 total=1          ✅ 不回归

turbo run test 132/132 全绿(合并后重跑);eslint 干净;check:route-envelope / check:error-code-casing / check:doc-authoring 全 PASS。

文档:data-api.mdx 增加「过滤器要么生效要么失败」小节与对照表;error-catalog.mdxINVALID_FILTER 条目此前只写「where 子句里的操作符或字段非法」,不再覆盖它现在回答的条件,已重写并补上这一族存在的理由。

对调用方的影响

带畸形过滤器的请求现在大声失败,而不是收到全部记录。所有合法形状——JSON 字符串、活对象、FilterCondition AST 数组、四种别名单独使用——不受影响。export?filter= 的错误码从 INVALID_REQUEST 变为 INVALID_FILTER(状态码与消息不变)。

家族收口

三案修完后,findData 的过滤器表达路径上不再有静默分支:未知参数(#4134)、被挤掉的谓词(#4164)、无法应用的过滤器(#4181/#4121)全部要么生效要么抛错。建议后续补一条家族级 conformance 把这条不变量固化。

… ignored (#4181)

`?filter={status:done` — one missing quote — answered 200 with the UNFILTERED
page. The JSON-parse tolerance (`catch { /* keep as-is */ }`) left the raw
string on `where`, a shape no driver consumes, so the filter was dropped whole
and the response was byte-for-byte a successful unfiltered query. Worst
failure direction in this family: #4134 returned nothing, #4164 dropped one
predicate, this returned everything.

This was not a design question. The sibling `GET /data/:object/export` route
has rejected the same input since it was written (400 INVALID_REQUEST,
"filter must be JSON") — the list path was the outlier, and the two routes of
one endpoint family answered the same malformed input in opposite ways. The
guard moves into the shared normalizer so `GET /data/:object`,
`POST /data/:object/query` and the runtime dispatcher inherit one answer.

- Unparseable JSON → 400 INVALID_REQUEST naming the parameter, and saying the
  filter was NOT applied — the failure mode is what makes it urgent.
- Parses but is not a filter (`?filter=5` / `"done"` / `null`) → same
  rejection. Usable JSON is not a usable filter.
- Blank `?filter=` stays ABSENT, not malformed — the same `length > 0` guard
  export applies. `where` is deleted rather than left as `''`.
- `filter` / `filters` / `$filter` / `where` are four spellings of ONE slot;
  `??` silently ran the first and discarded the rest. Different values are now
  400 (merging would invent an intent the caller never expressed; picking one
  IS the silent drop). Identical redundant spellings pass.
- Export's `orderby` gets the same rule — a sort that cannot be parsed is
  refused, not dropped. Lower stakes (row set unchanged) but a caller taking
  "latest N" via orderby+top silently got an arbitrary N.

Because a filter now either parses to an object or 400s, #4164's
`typeof explicitWhere === 'object'` merge guard — added precisely because this
tolerance could leave a raw string on `where` — is unreachable and removed
with it. Its pin test flips from "keeps the garbage" to "rejects it".

Verified live on the showcase app: garbage filter 400s alone and with `top`;
`?filter=5|"done"|null` 400; blank filter, all four aliases, and valid JSON /
object / AST-array shapes still 200; export filter+orderby 400 while a normal
export streams 6080B; #4134 (`?pageSize=5`) and #4164 (merged total=1) both
unregressed. `turbo run test` 132/132.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKsxtrsCwSL3uAcxAdVj83
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 4:09pm

Request Review

claude added 2 commits July 30, 2026 15:42
…own-query-params-6syn6f

# Conflicts:
#	packages/metadata-protocol/src/protocol.ts
Reconciles with #4121 (`fix(metadata)!: a $filter array that is not a filter
AST is rejected`), which landed on this same code path while #4181 was in
flight and is why this branch conflicted.

The two fixes are complementary halves of one condition — #4121 catches the
malformed ARRAY shapes, #4181 the non-array ways a filter fails to become one
(unparseable JSON; JSON that parses to a number / bare string / null) — so
they now share the standard-catalog `INVALID_FILTER` code and a matching pair
of helpers (`malformedFilterArrayError` / `unusableFilterError`; the name
collision between them is what git could not resolve). A caller asking "did my
filter run?" must not have to know which branch caught it, and a new test pins
exactly that: four differently-broken filters, one `400/INVALID_FILTER`.

The export route's filter guard moves `INVALID_REQUEST` → `INVALID_FILTER` for
the same reason. That code WAS pinned by a test — my earlier grep looked for
the message string and missed an assertion on the code — so the change is
deliberate and the test now records why. Export's `orderby` guard keeps
`INVALID_REQUEST`: a sort is not a filter, and it never returned a wrong row
set. That guard also gains the test it never had.

Ordering in the chain is what makes them compose: blank → absent; string →
JSON.parse or reject; array → AST-convert or reject (#4121); scalar → reject
(#4181); everything surviving is an object, which is what lets #4164's merge
trust `where`. An empty `[]` still means "no filter" and passes.

#4121's 12 tests and this branch's 103 both green; turbo test 132/132.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKsxtrsCwSL3uAcxAdVj83
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/rest.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/releases/implementation-status.mdx (via @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…ers (#4181)

The entry described only "an invalid operator or field in the where clause".
After #4121 and #4181 the same code also answers a $filter array that is not a
filter AST, a filter parameter that is not valid JSON, and JSON that parses to
a non-filter — so a caller who hit one of those found a catalog entry that did
not describe their request.

Adds the sentence the whole family exists for: a filter the server cannot run
is never skipped, because skipping it returns the UNFILTERED set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKsxtrsCwSL3uAcxAdVj83
@github-actions github-actions Bot added size/l and removed size/m labels Jul 30, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review July 30, 2026 16:16
@os-zhuang
os-zhuang merged commit 6c87cc9 into main Jul 30, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/rest-list-unknown-query-params-6syn6f branch July 30, 2026 16:16
os-zhuang pushed a commit that referenced this pull request Aug 1, 2026
…without the driver prefix (#4436)

Completes the WIP commit: adds the remaining sql-driver throw sites and the
regression tests for both backends.

#4209/#4029/#3948 settled the POSTURE — a filter carrying an operator the
driver cannot compile is refused instead of silently matching every row. What
was missing is the refusal's IDENTITY on the wire. The driver threw a bare
`Error`, so `mapDataError` fell through to its default branch and served a body
whose only key was `error`:

  GET /api/v1/data/showcase_task?filter={"title":{"$bogusop":"x"}}
  → 400 {"error":"[sql-driver] Unsupported filter operator \"$bogusop\" …"}

Two contract breaks in one body — no `error.code` at all on a route whose
sibling rejections all speak the ADR-0112 catalogue, and the driver-internal
`[sql-driver]` prefix on the wire, which is what the #3867 sanitiser exists to
stop.

Fixed at the throw site (PD #12), not by teaching the REST layer to guess:
both drivers now refuse through an `unsupportedFilterError` helper that stamps
`code = StandardErrorCode.enum.INVALID_FILTER` — the constant, so a catalogue
rename breaks the compile — and `status = 400`. `INVALID_FILTER` is the same
code `metadata-protocol` already emits when a filter fails to parse upstream
(`malformedFilterArrayError` / `unusableFilterError`): one condition, one wire
code, however the caller reached it. The `status` also puts the rejection on
`isExpectedQueryRejection`, so a client mistake stops being logged as an
unhandled server error.

Applied to every filter-COMPILATION refusal in both backends, not only the one
branch the issue names: unsupported operator ($-object, legacy triple),
unrecognised logical keyword, unrecognised element type, and a `between` /
`$between` operand that is not a two-element array. They are the same envelope
defect on adjacent lines, and #3948 made the two drivers agree that an
uncompilable filter is a refusal — so their refusal envelopes have to agree
too, or the cross-driver parity this repo relies on is false where it matters.

Tests: new `sql-driver-filter-refusal-envelope.test.ts` (8) and
`memory-filter-refusal-envelope.test.ts` (5) pin `code`, `status`, the absence
of the internal prefix, and that the actionable operator/field/vocabulary
detail survives. Full suites green: driver-sql 623 passed, driver-memory 286
passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 2, 2026
…bjectstack-ai#4435, objectstack-ai#4436, objectstack-ai#4483) (objectstack-ai#4496)

* fix(spec): the $search auto field set's lead ORDERS the set, it must not admit one (objectstack-ai#4483)

`autoDefaultFields` filtered every field through three exclusions
(`SEARCH_AUTO_EXCLUDED_FIELDS`, `hidden`, unsearchable type) and then
prepended the display/name/title field on an EXISTENCE check alone — so the
exclusions did not hold for whichever field happened to lead, and the module's
own "system / audit / heavy fields never auto-included" invariant was false.

Not a contrived shape: ADR-0079's `provisionPrimary(schema, { synthesize:
false })` designates `nameField` at registration, and on a table whose only
textual column IS the primary key (system tables, junction tables, append-only
logs) it designates `id`. `$search` then expanded to `{ id: { $contains: term } }`
— a substring scan over the primary key, returning a narrow and semantically
wrong row set.

It loosened a second layer too: `resolveSearchFieldResolution` is also the
objectstack-ai#4254 REST ingress gate's arbiter for "would the engine actually scan this
field", so with `id` in `allowed` a `$searchFields=id` override was ACCEPTED
rather than refused.

The lead's job is to put the primary title FIRST, never to admit it, so it is
now chosen from the already-filtered set. An excluded / hidden / unsearchable
display field simply does not lead and the set is unchanged; an eligible one
still leads, so the ordering intent is intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* wip(drivers): give the uncompilable-filter refusal an ADR-0112 code and drop the driver prefix (objectstack-ai#4436)

IN PROGRESS — code change complete, regression test not yet written and the
real-boot curl repro not yet run.

A filter carrying an operator the driver cannot compile is already REFUSED
rather than silently matched (objectstack-ai#4209/objectstack-ai#4029), but the refusal had no wire
identity: the thrown `Error` carried no `code`, so `mapDataError`'s default
branch served `{"error": "[sql-driver] Unsupported filter operator …"}` — no
`error.code` at all, breaking the ADR-0112 contract every sibling rejection on
the same route already honours (`INVALID_FIELD`, `INVALID_FILTER`,
`RECORD_NOT_FOUND`), and leaking the `[sql-driver]` internal prefix that the
objectstack-ai#3867 sanitiser exists to keep off the wire.

Both drivers now throw through a local `unsupportedFilterError` that stamps
`code = StandardErrorCode.enum.INVALID_FILTER` (the same catalogued code
`metadata-protocol` emits when a filter fails to parse upstream — one
condition, one wire code however the caller reached it) and `status = 400`,
which also puts the rejection on `isExpectedQueryRejection` so a client mistake
stops being logged as an unhandled server error. The internal prefix is gone
from the message; the actionable operator/field/vocabulary detail stays.

Applied to every filter-COMPILATION refusal in both backends, not just the one
branch the issue names — they are the same envelope defect on adjacent lines,
and objectstack-ai#3948 made the two drivers agree that an uncompilable filter is a refusal,
so their refusal envelopes have to agree too.

TODO: regression tests (driver-sql, driver-memory, REST envelope) + boot repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* fix(drivers): the uncompilable-filter refusal speaks INVALID_FILTER, without the driver prefix (objectstack-ai#4436)

Completes the WIP commit: adds the remaining sql-driver throw sites and the
regression tests for both backends.

objectstack-ai#4209/objectstack-ai#4029/objectstack-ai#3948 settled the POSTURE — a filter carrying an operator the
driver cannot compile is refused instead of silently matching every row. What
was missing is the refusal's IDENTITY on the wire. The driver threw a bare
`Error`, so `mapDataError` fell through to its default branch and served a body
whose only key was `error`:

  GET /api/v1/data/showcase_task?filter={"title":{"$bogusop":"x"}}
  → 400 {"error":"[sql-driver] Unsupported filter operator \"$bogusop\" …"}

Two contract breaks in one body — no `error.code` at all on a route whose
sibling rejections all speak the ADR-0112 catalogue, and the driver-internal
`[sql-driver]` prefix on the wire, which is what the objectstack-ai#3867 sanitiser exists to
stop.

Fixed at the throw site (PD objectstack-ai#12), not by teaching the REST layer to guess:
both drivers now refuse through an `unsupportedFilterError` helper that stamps
`code = StandardErrorCode.enum.INVALID_FILTER` — the constant, so a catalogue
rename breaks the compile — and `status = 400`. `INVALID_FILTER` is the same
code `metadata-protocol` already emits when a filter fails to parse upstream
(`malformedFilterArrayError` / `unusableFilterError`): one condition, one wire
code, however the caller reached it. The `status` also puts the rejection on
`isExpectedQueryRejection`, so a client mistake stops being logged as an
unhandled server error.

Applied to every filter-COMPILATION refusal in both backends, not only the one
branch the issue names: unsupported operator ($-object, legacy triple),
unrecognised logical keyword, unrecognised element type, and a `between` /
`$between` operand that is not a two-element array. They are the same envelope
defect on adjacent lines, and objectstack-ai#3948 made the two drivers agree that an
uncompilable filter is a refusal — so their refusal envelopes have to agree
too, or the cross-driver parity this repo relies on is false where it matters.

Tests: new `sql-driver-filter-refusal-envelope.test.ts` (8) and
`memory-filter-refusal-envelope.test.ts` (5) pin `code`, `status`, the absence
of the internal prefix, and that the actionable operator/field/vocabulary
detail survives. Full suites green: driver-sql 623 passed, driver-memory 286
passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* fix(metadata-protocol): PATCH/DELETE of a nonexistent record answer RECORD_NOT_FOUND, not 200 (objectstack-ai#4435)

The READ path was already honest — `getData` on an unknown id answers `404
RECORD_NOT_FOUND`. Both single-record WRITE paths reported success for a record
that does not exist:

  PATCH  /data/showcase_task/definitely_not_a_row  → 200 {"record":null}
  DELETE /data/showcase_task/definitely_not_a_row  → 200 {"success":true}

REST is a pass-through here (`res.json(await p.deleteData(...))`), so these are
the protocol's answers and this is where they are fixed.

What it cost: a client that PATCHed a concurrently deleted record was told the
write landed, and had to null-check a SUCCESS payload to find out otherwise;
`DELETE` said `success: true` for any string in the path, so a typo'd id, an
already-deleted row and a real deletion were indistinguishable — including in
bulk, where `deleteMany {"ids":["nonexistent_1"]}` answered `succeeded: 1`. It
is the same silent-no-op shape the v17 train removed everywhere else this
window (objectstack-ai#4240/objectstack-ai#4303/objectstack-ai#4315, objectstack-ai#4169, objectstack-ai#4190), one level up.

- `updateData` asks existence BEFORE the write, via the same `findOne` +
  caller context `getData` uses. Deliberately not a post-check on the returned
  row: the engine returns the post-write READBACK, which is also `null` when
  the row still exists but the write moved it out of the caller's row scope
  (reassigning `owner_id` away from yourself under an owner-scoped policy) —
  reading that as "not found" would 404 a write that succeeded.
- `deleteData` and `deleteManyData` read the driver's own answer. The contract
  (`IDataDriver.delete` — "True if deleted, false if not found") already
  carried it; the code discarded it and pushed a literal `success: true`.
  Read as `=== false` on purpose: that is the contract's positive not-found
  value, while a driver returning the deleted row or an off-contract
  `undefined` gives no such signal, and inventing a 404 from a falsy return
  would break deletes against third-party drivers instead of reporting
  honestly. `success` on the 200 now means what it says.
- The 404 envelope is extracted as `recordNotFoundError` so the read and the
  two write paths cannot drift apart again.

Note on the issue's second half: the spec's `DeleteDataResponseSchema` declares
`success`, not `deleted`, so the existing key is correct as-is and nothing
renames.

Tests: new `protocol.record-not-found.test.ts` (12) covers PATCH/DELETE/
deleteMany, the read/write agreement on the same id, delete-twice, mixed
batches, the `=== false` reading, and that the existence probe is asked with
the caller's context. Three `protocol.dropped-fields.test.ts` fixtures stubbed
`findOne → null` while PATCHing — under the new contract that IS a 404, so they
now describe an engine that has the row (they are about the strip channel, not
about missing records). Suites green: metadata-protocol 169, rest + objectql
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* fix(runtime): a sandbox capability denial is a 500 crash, not a 400 rejection (objectstack-ai#4431)

The `action-crash-vs-rejection` contract (objectstack-ai#3951) pins the table: a
`SandboxError` WITH `innerMessage` is a body's deliberate throw → 400; a
`SandboxError` with NO `innerMessage` — timeout, capability denial — is a crash
→ 500. Capability denials were answering 400:

  POST /api/v1/actions/showcase_task/rc1_crash_probe
  → 400 {"error":{"code":"VALIDATION_ERROR",
         "message":"SandboxError: capability 'api.read' not granted to action …"}}

Why: the gate throws `SandboxError` synchronously INSIDE a QuickJS host
function, which rejects the async IIFE inside the VM, so it returns through the
`__error` side-channel — and the pump loop presumed everything arriving there
was user code throwing on purpose, setting `innerMessage` unconditionally. The
dispatcher's classifier then read that as a deliberate rejection. So every
capability denial stayed invisible to gateway error rates, APM and alerting —
exactly the blindness objectstack-ai#3951 was written to close — and the client also received
the `SandboxError: ` debug prefix that belongs only in server logs.

`SandboxError`'s own jsdoc already said `innerMessage` is undefined for the
sandbox's internal errors; that only held for denials detected OUTSIDE
evaluation (a timeout, which takes the separate `budgetError` path). In-VM
host-call denials — `ctx.api.*`, `ctx.log`, `ctx.crypto`, `ctx.api.transaction`
— were misclassified.

Fix: the sandbox's own faults now carry a marker THROUGH the VM.
`hostErrorToVm` stamps `__objectstackSandboxFault` on any `SandboxError` it
marshals, and the synchronous gates throw the VM handle it builds rather than a
raw host error — quickjs-emscripten passes a thrown handle through verbatim
while its `newError` path copies only `name`/`message`, which is precisely how
the identity was lost. The reject handler reports the marker on the additive
`__errorInfo` channel, and the pump loop, seeing it, rethrows with neither the
`<kind> '<name>' threw:` wrapper (nothing threw — the sandbox refused) nor an
`innerMessage`. The existing classifier then does the rest: name is
`SandboxError`, no inner/code/fields ⇒ unexpected fault ⇒ `errorFromThrown(err,
500)`, and the message reaching the client is the capability text with the
debug prefix stripped.

A marker rather than a match on the flattened `SandboxError: …` text, because
the flattening is user-reachable: a body that CATCHES the denial and throws its
own business error must keep its 400, and that case is pinned.

No ADR or contract was changed — this makes the runtime deliver the contract
objectstack-ai#3951 already specifies.

Tests: new `sandbox/capability-denial-is-a-fault.test.ts` (7) covers all four
in-VM gates, the absence of innerMessage/code/fields, the prefix, the
caught-and-rethrown rejection, an ordinary deliberate throw, and that a record
`ValidationError` crossing `ctx.api` keeps its `code`/`fields` (the marker must
not turn every failed write into a 500). Verified failing on all four denial
cases before the fix. Runtime suite green: 73 files / 1033 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

* chore: add changeset for the v17 REST envelope defects (objectstack-ai#4431, objectstack-ai#4435, objectstack-ai#4436, objectstack-ai#4483)

* fix(test): call syncSchema with its real (object, schema) signature (objectstack-ai#4436)

The objectstack-ai#4436 refusal-envelope test passed a single merged object where the
driver takes the object name as its own first argument, so the suite could
not type-check. Matches the idiom in the sibling memory-driver tests.

* fix(metadata-protocol): one probe per PATCH, and the existence gate is not an RLS gate (objectstack-ai#4435)

Follow-up to 959b838, fixing two defects the first cut introduced. Both were
caught by CI (`Test Core` on @objectstack/objectql, `Dogfood Regression Gate
1/2`), and the second is the more serious of the two.

## 1. The existence probe duplicated OCC's read

`updateData` called `assertVersionMatch` (which reads the row for its
`updated_at`) and then `assertRecordExists` (which reads the same row again).
Two round-trips per PATCH — a performance regression no gate reports — and the
`protocol-data.test.ts` OCC cases said so directly ("expected to be called
once, but got 2 times").

The two gates want the same row, so they now share one read: `probeRecord`
fetches it, `assertVersionOf` became a PURE comparison over an
already-read row, and `assertVersionMatch` survives only for `deleteData`,
which needs no existence probe at all — the driver's own return reports whether
a row matched, so a plain DELETE stays at zero extra reads and only an OCC
token buys one.

## 2. The probe must ask EXISTENCE, not the caller's visibility

The first cut probed with the CALLER's context, reasoning that it should match
`getData`. That quietly turned the existence gate into an authorization gate: a
row the caller cannot read comes back `null`, so the PATCH answers 404. Two
things break.

It moves an RLS decision out of the write policy. Whether an unreadable row may
be written by id is the objectstack-ai#1994 pre-image check's call, made inside
`engine.update`. A probe in front of it adds a second, different rule — scope
creep into the security model, out of a bug fix about missing records.

And it disarms a revert-provable security proof. `@proof: rls-by-id-write`
(`qa/dogfood/test/rls-fixture.dogfood.test.ts`, referenced by the
`permission.rowLevelSecurity.using` liveness ledger entry) boots a fixture whose
member can read nothing and has no write policy, and asserts the runner reports
`rls-hole` — the RED half that proves the gate can go red at all. A
caller-scoped probe 404s that PATCH and the proof goes green: if objectstack-ai#1994 were ever
reverted, this probe would MASK it. Accidentally hardening one path is not worth
permanently blinding the gate that watches the whole class.

So the probe runs as system and answers existence only. Authorization stays
exactly where it was, and the sole behaviour added is the 404 the issue asked
for: an id that names no row at all.

Tests: `protocol-data.test.ts`'s OCC block now asserts the new contract — one
probe on every PATCH (the existence probe, no OCC comparison without a token),
still exactly one when OCC IS requested (the anti-duplication pin), 404 before
any OCC verdict for a missing id, and DELETE without a token issuing no probe.
Its fixtures now supply a row, because under this contract a PATCH of an absent
record is correctly a 404 and those cases are about OCC. Two cases added to
`protocol.record-not-found.test.ts` pin the system-context probe and that an
unreadable-but-existing row still reaches the engine for RLS to decide.

Green: objectql protocol-data 117, metadata-protocol 170, dogfood shard 1/2
38 files / 235 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REST 列表:无法解析的 filter JSON 被静默忽略 —— 返回未过滤整页(#4134/#4164 家族第三员)

2 participants